Coverage Report

Created: 2026-04-26 08:04

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\csshw\csshw\src\client\mod.rs
Line
Count
Source
1
//! Client implementation
2
3
#![deny(clippy::implicit_return)]
4
#![allow(clippy::needless_return, clippy::doc_overindented_list_items)]
5
#![warn(missing_docs)]
6
7
use log::{error, info, warn};
8
use std::fs::File;
9
use std::io::{self, BufReader};
10
use std::path::Path;
11
use std::time::Duration;
12
use windows::Win32::UI::Input::KeyboardAndMouse::VK_C;
13
14
use crate::utils::config::ClientConfig;
15
use crate::utils::windows::{get_console_title, WindowsApi};
16
use ssh2_config::{ParseRule, SshConfig};
17
use tokio::net::windows::named_pipe::NamedPipeClient;
18
use tokio::process::{Child, Command};
19
use tokio::{io::Interest, net::windows::named_pipe::ClientOptions};
20
use windows::Win32::System::Console::{
21
    INPUT_RECORD, INPUT_RECORD_0, KEY_EVENT, KEY_EVENT_RECORD, LEFT_ALT_PRESSED, RIGHT_ALT_PRESSED,
22
    SHIFT_PRESSED,
23
};
24
25
use crate::{
26
    serde::{
27
        deserialization::deserialize_input_record_0, serialization::serialize_pid,
28
        SERIALIZED_INPUT_RECORD_0_LENGTH, SERIALIZED_PID_LENGTH,
29
    },
30
    utils::constants::{PIPE_NAME, PKG_NAME},
31
};
32
33
/// Possible results when reading from the named pipe and writing to the
34
/// current process's stdinput.
35
enum ReadWriteResult {
36
    /// We wrote all complete [INPUT_RECORD_0] sequences we read from
37
    /// the named pipe to stdin.
38
    Success {
39
        /// Incomplete [INPUT_RECORD_0] sequence.
40
        ///
41
        /// What we read from the named pipe is a serialized [INPUT_RECORD_0].`KeyEvent`.
42
        /// As this is simply a [`SERIALIZED_INPUT_RECORD_0_LENGTH`] byte long sequence and we try to read from the pipe until we
43
        /// have some of the data it can happen that during any one read/write iteration we don't
44
        /// read the full sequence so we must keep track of what we read for next iterations
45
        /// where we will be able to read the remainder of the sequence.
46
        remainder: Vec<u8>,
47
        /// List of [KEY_EVENT_RECORD]s we have read from the named pipe.
48
        ///
49
        /// Used to detect the `Alt + Shift + C` key combination used
50
        /// to close the console window after the client process encountered an unexpected error.
51
        key_event_records: Vec<KEY_EVENT_RECORD>,
52
    },
53
    /// Trying to read from the pipe would require us to wait for data.
54
    WouldBlock,
55
    /// Something went wrong.
56
    Err,
57
    /// The pipe was closed.
58
    Disconnect,
59
}
60
61
/// Write the given [INPUT_RECORD_0] to the console input buffer using the provided API.
62
///
63
/// # Arguments
64
///
65
/// * `api` - The Windows API implementation to use.
66
/// * `input_record` - The [INPUT_RECORD_0].`KeyEvent` input record to write.
67
3
fn write_console_input(api: &dyn WindowsApi, input_record: INPUT_RECORD_0) {
68
3
    let buffer: [INPUT_RECORD; 1] = [INPUT_RECORD {
69
3
        EventType: KEY_EVENT as u16,
70
3
        Event: input_record,
71
3
    }];
72
3
    let mut nb_of_events_written = 0u32;
73
3
    match api.write_console_input(&buffer, &mut nb_of_events_written) {
74
        Ok(_) => {
75
2
            if nb_of_events_written == 0 {
76
1
                error!("Failed to write console input");
77
1
                error!("{:?}", 
api0
.
get_last_error0
());
78
1
            }
79
        }
80
        Err(_) => {
81
1
            error!("Failed to write console input");
82
1
            error!("{:?}", 
api0
.
get_last_error0
());
83
        }
84
    };
85
3
}
86
87
/// Resolve the username from the provided value or SSH config.
88
///
89
/// # Arguments
90
///
91
/// * `username` - Optional username to use. If None, will try to resolve from SSH config.
92
/// * `host` - The hostname (without port) to connect to.
93
/// * `config` - The client configuration containing SSH config path.
94
///
95
/// # Returns
96
///
97
/// The resolved username.
98
12
fn resolve_username(username: Option<String>, host: &str, config: &ClientConfig) -> String {
99
12
    if let Some(
val8
) = username {
100
8
        return val;
101
4
    }
102
103
4
    let mut ssh_config = SshConfig::default();
104
4
    let ssh_config_path = Path::new(config.ssh_config_path.as_str());
105
4
    if ssh_config_path.exists() {
106
2
        let mut reader = BufReader::new(
107
2
            File::open(ssh_config_path).expect("Could not open SSH configuration file."),
108
2
        );
109
2
        ssh_config = SshConfig::default()
110
2
            .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
111
2
            .expect("Failed to parse SSH configuration file");
112
2
    }
113
4
    return ssh_config
114
4
        .query(<&str>::clone(&host))
115
4
        .user
116
4
        .unwrap_or_default();
117
12
}
118
119
/// Build the SSH arguments from the username, host, port, and config.
120
///
121
/// # Arguments
122
///
123
/// * `username`    - The username to connect with.
124
/// * `host`        - The hostname to connect to.
125
/// * `port`        - Optional port number (0-65535).
126
/// * `config`      - The client config indicating how to call the SSH program.
127
///
128
/// # Returns
129
///
130
/// A vector of arguments ready to be passed to the SSH command.
131
12
fn build_ssh_arguments(
132
12
    username: &str,
133
12
    host: &str,
134
12
    port: Option<u16>,
135
12
    config: &ClientConfig,
136
12
) -> Vec<String> {
137
12
    let username_host = format!("{username}@{host}");
138
139
12
    let mut arguments = replace_argument_placeholders(
140
12
        &config.arguments,
141
12
        &config.username_host_placeholder,
142
12
        &username_host,
143
    );
144
145
    // Add port arguments if port was specified
146
12
    if let Some(
port9
) = port {
147
9
        arguments.push("-p".to_string());
148
9
        arguments.push(port.to_string());
149
9
    
}3
150
151
12
    return arguments;
152
12
}
153
154
/// Launch the SSH process.
155
///
156
/// The process might overwrite the console title once it launched, so we wait for that
157
/// to happen and set the title again.
158
///
159
/// # Arguments
160
///
161
/// * `username`    - The username to connect with.
162
/// * `host`        - The hostname to connect to.
163
/// * `port`        - Optional port number (0-65535).
164
/// * `config`      - The client config indicating how to call the SSH program.
165
///
166
/// # Returns
167
///
168
/// The handle to created [Child] process.
169
0
async fn launch_ssh_process(
170
0
    username: &str,
171
0
    host: &str,
172
0
    port: Option<u16>,
173
0
    config: &ClientConfig,
174
0
) -> Child {
175
0
    let arguments = build_ssh_arguments(username, host, port, config);
176
0
    let child = Command::new(&config.program)
177
0
        .args(arguments.clone())
178
0
        .spawn()
179
0
        .unwrap_or_else(|err| {
180
0
            let args: String = arguments.join(" ");
181
0
            error!("{}", err);
182
0
            panic!(
183
                "Failed to launch process `{}` with arguments `{}`",
184
                config.program, args
185
            )
186
        });
187
0
    return child;
188
0
}
189
190
/// Read all available [INPUT_RECORD_0] from the named pipe and write them to the console input buffer using the provided API.
191
///
192
/// This function also extracts the [KEY_EVENT_RECORD]s, making them available to the caller via
193
/// `ReadWriteResult::Success` and handles incomple reads from the named pipe via the internal buffer.
194
///
195
/// The daemon might send a "keep alive packet", which is just [`SERIALIZED_INPUT_RECORD_0_LENGTH`] bytes of `1`s,
196
/// we ignore this.
197
///
198
/// # Arguments
199
///
200
/// * `api`                 - The Windows API implementation to use.
201
/// * `named_pipe_client`   - The [Windows named pipe][1] client that has successfully connected to
202
///                           the named pipe created by the daemon.
203
/// * `internal_buffer`     - Vector containing incomplete `SERIALIZED_INPUT_RECORD_0` sequences
204
///                           that were read in a previous call.
205
/// # Returns
206
///
207
/// A `ReadWriteResult` indicating whether we were able to read from the named pipe and write the available INPUT_RECORDs
208
/// to the console input buffer or not.
209
///
210
/// [1]: https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipes
211
0
async fn read_write_loop(
212
0
    api: &dyn WindowsApi,
213
0
    named_pipe_client: &NamedPipeClient,
214
0
    internal_buffer: &mut Vec<u8>,
215
0
) -> ReadWriteResult {
216
0
    let mut buf: [u8; SERIALIZED_INPUT_RECORD_0_LENGTH * 10] =
217
0
        [0; SERIALIZED_INPUT_RECORD_0_LENGTH * 10];
218
0
    match named_pipe_client.try_read(&mut buf) {
219
        Ok(0) => {
220
            // Seems to only happen if the pipe is closed/server disconnects
221
            // indicating that the daemon has been closed.
222
            // Exit the client too in that case.
223
0
            return ReadWriteResult::Disconnect;
224
        }
225
0
        Ok(n) => {
226
0
            internal_buffer.extend(&mut buf[0..n].iter());
227
0
            let iter = internal_buffer.chunks_exact(SERIALIZED_INPUT_RECORD_0_LENGTH);
228
0
            let mut key_event_records: Vec<KEY_EVENT_RECORD> = Vec::new();
229
0
            for serialzied_input_record in iter.clone() {
230
0
                if is_keep_alive_packet(serialzied_input_record) {
231
0
                    continue;
232
0
                };
233
0
                let input_record = deserialize_input_record_0(serialzied_input_record);
234
0
                write_console_input(api, input_record);
235
0
                key_event_records.push(unsafe { input_record.KeyEvent });
236
            }
237
0
            return ReadWriteResult::Success {
238
0
                remainder: iter.remainder().to_vec(),
239
0
                key_event_records,
240
0
            };
241
        }
242
0
        Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
243
0
            return ReadWriteResult::WouldBlock;
244
        }
245
0
        Err(e) => {
246
0
            error!("{}", e);
247
0
            return ReadWriteResult::Err;
248
        }
249
    }
250
0
}
251
252
/// Checks if a key event represents the Alt+Shift+C combination.
253
///
254
/// # Arguments
255
///
256
/// * `key_event` - The key event record to check.
257
///
258
/// # Returns
259
///
260
/// `true` if the key event represents Alt+Shift+C, `false` otherwise.
261
8
fn is_alt_shift_c_combination(key_event: &KEY_EVENT_RECORD) -> bool {
262
8
    return (key_event.dwControlKeyState & LEFT_ALT_PRESSED >= 1
263
3
        || key_event.dwControlKeyState & RIGHT_ALT_PRESSED == 1)
264
6
        && key_event.dwControlKeyState & SHIFT_PRESSED >= 1
265
5
        && key_event.wVirtualKeyCode == VK_C.0;
266
8
}
267
268
/// Checks if a byte sequence represents a keep-alive packet.
269
///
270
/// # Arguments
271
///
272
/// * `packet` - The byte sequence to check.
273
///
274
/// # Returns
275
///
276
/// `true` if the packet is a keep-alive packet, `false` otherwise.
277
6
fn is_keep_alive_packet(packet: &[u8]) -> bool {
278
6
    return packet == [u8::MAX; SERIALIZED_INPUT_RECORD_0_LENGTH];
279
6
}
280
281
/// Replaces placeholders in SSH command arguments.
282
///
283
/// # Arguments
284
///
285
/// * `arguments` - The argument templates.
286
/// * `placeholder` - The placeholder string to replace.
287
/// * `replacement` - The value to replace the placeholder with.
288
///
289
/// # Returns
290
///
291
/// A vector of arguments with placeholders replaced.
292
12
fn replace_argument_placeholders(
293
12
    arguments: &[String],
294
12
    placeholder: &str,
295
12
    replacement: &str,
296
12
) -> Vec<String> {
297
12
    return arguments
298
12
        .iter()
299
30
        .
map12
(|arg| return arg.replace(placeholder, replacement))
300
12
        .collect();
301
12
}
302
303
/// Send this process's id over the pipe to the daemon as a 4 byte
304
/// little-endian sequence.
305
///
306
/// The daemon uses the PID to match the pipe connection to the correct
307
/// [`crate::daemon`] `Client` entry. Without this handshake the daemon will
308
/// not forward any input records.
309
///
310
/// # Arguments
311
///
312
/// * `named_pipe_client` - The connected pipe client to write the PID to.
313
///
314
/// # Panics
315
///
316
/// Panics if the pipe write fails in a way that cannot be retried.
317
1
async fn send_pid_handshake(named_pipe_client: &NamedPipeClient) {
318
1
    let pid_bytes = serialize_pid(std::process::id());
319
1
    let mut written = 0usize;
320
2
    while written < SERIALIZED_PID_LENGTH {
321
1
        named_pipe_client.writable().await.unwrap_or_else(|err| 
{0
322
0
            panic!("Named pipe client is not writable for PID handshake: {err}")
323
        });
324
1
        match named_pipe_client.try_write(&pid_bytes[written..]) {
325
            Ok(0) => {
326
0
                panic!("Named pipe closed before PID handshake could complete");
327
            }
328
1
            Ok(n) => {
329
1
                written += n;
330
1
            }
331
0
            Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
332
0
                continue;
333
            }
334
0
            Err(e) => {
335
0
                panic!("Failed to send PID handshake to daemon: {e}");
336
            }
337
        }
338
    }
339
1
    return;
340
1
}
341
342
/// The main run loop of the client.
343
///
344
/// Connects to the named pipe opened by the daemon, reads all input records from it
345
/// and replays them to the console input buffer of the given child process.
346
/// Handles the `Alt + Shift + C` key combination used to close the console window
347
/// after the child process encountered an unexpected error.
348
///
349
/// # Arguments
350
///
351
/// * `api` - The Windows API implementation to use.
352
/// * `child` - Handle to the running SSH process.
353
0
async fn run(api: &dyn WindowsApi, child: &mut Child) {
354
    // Many clients trying to open the pipe at the same time can cause
355
    // a file not found error, so keep trying until we managed to open it
356
0
    let named_pipe_client: NamedPipeClient = loop {
357
0
        match ClientOptions::new().open(PIPE_NAME) {
358
0
            Ok(named_pipe_client) => {
359
0
                break named_pipe_client;
360
            }
361
            Err(_) => {
362
0
                continue;
363
            }
364
        }
365
    };
366
    // Identify ourselves to the daemon's pipe server by sending our PID.
367
    // The daemon uses this to correlate this pipe connection to the corresponding
368
    // client in its internal bookkeeping.
369
0
    send_pid_handshake(&named_pipe_client).await;
370
0
    let mut child_error = false;
371
0
    let mut internal_buffer: Vec<u8> = Vec::new();
372
    loop {
373
0
        named_pipe_client
374
0
            .ready(Interest::READABLE)
375
0
            .await
376
0
            .unwrap_or_else(|err| {
377
0
                error!("{}", err);
378
0
                panic!("Named client pipe is not ready to be read",)
379
            });
380
381
0
        match read_write_loop(api, &named_pipe_client, &mut internal_buffer).await {
382
            ReadWriteResult::Success {
383
0
                remainder,
384
0
                key_event_records,
385
            } => {
386
0
                internal_buffer = remainder;
387
0
                if child_error {
388
0
                    for key_event in key_event_records.into_iter() {
389
0
                        if is_alt_shift_c_combination(&key_event) {
390
0
                            return;
391
0
                        }
392
                    }
393
0
                }
394
            }
395
            ReadWriteResult::WouldBlock | ReadWriteResult::Err => {
396
                // Sleep some time to avoid hogging 100% CPU usage.
397
0
                tokio::time::sleep(Duration::from_nanos(5)).await;
398
            }
399
            ReadWriteResult::Disconnect => {
400
0
                warn!("Encountered disconnect when trying to read from named pipe");
401
0
                break;
402
            }
403
        }
404
0
        match child.try_wait() {
405
0
            Ok(Some(exit_status)) => match exit_status.code().unwrap() {
406
                0 | 1 | 130 => {
407
                    // 0 -> last command successful
408
                    // 1 -> last command unsuccessful
409
                    // 130 -> last command cancelled (Ctrl + C)
410
0
                    info!(
411
                        "Application terminated, last exit code: {}",
412
0
                        exit_status.code().unwrap()
413
                    );
414
0
                    break;
415
                }
416
                _ => {
417
0
                    if !child_error {
418
0
                        println!("Failed to establish SSH connection: {exit_status}");
419
0
                        println!("Shift-Alt-C to exit");
420
0
                        child_error = true;
421
0
                    }
422
                }
423
            },
424
0
            Ok(None) => (
425
0
                // child is still running
426
0
            ),
427
0
            Err(e) => panic!("{}", e),
428
        }
429
    }
430
0
}
431
432
/// The entrypoint for the `client` subcommand with API dependency injection.
433
///
434
/// Spawns a tokio background thread to ensure the console window title is not replaced
435
/// by the name of the child process once its launched.
436
/// Starts the SSH process as child process.
437
/// Executes the main run loop.
438
///
439
/// # Arguments
440
///
441
/// * `api`         - The Windows API implementation to use.
442
/// * `host`        - The name of the host to connect to, optionally with `:port` suffix.
443
/// * `username`    - The username to be used.
444
///                   Will try to resolve the correct username from the ssh config
445
///                   if none is given.
446
/// * `cli_port`    - Optional port from CLI option. Inline port takes precedence.
447
/// * `config`      - A reference to the `ClientConfig`.
448
0
pub async fn main(
449
0
    api: &dyn WindowsApi,
450
0
    host: String,
451
0
    username: Option<String>,
452
0
    cli_port: Option<u16>,
453
0
    config: &ClientConfig,
454
0
) {
455
0
    let (host, inline_port) =
456
0
        host.rsplit_once(':')
457
0
            .map_or((host.as_str(), None), |(host, port)| {
458
0
                return (host, Some(port));
459
0
            });
460
0
    let inline_port = inline_port.and_then(|p| {
461
0
        return p
462
0
            .parse::<u16>()
463
0
            .map_err(|e| {
464
0
                warn!("Invalid port '{}': {}. Using default SSH port.", p, e);
465
0
            })
466
0
            .ok();
467
0
    });
468
    // Inline port takes precedence over CLI port
469
0
    let port = inline_port.or(cli_port);
470
471
    // Resolve username using SSH config if needed
472
0
    let resolved_username = resolve_username(username, host, config);
473
474
    // Create title for console window
475
0
    let title_host = if let Some(port) = port {
476
0
        format!("{host}:{port}")
477
    } else {
478
0
        host.to_string()
479
    };
480
0
    let username_host_title = format!("{resolved_username}@{title_host}");
481
0
    let console_title = format!("{PKG_NAME} - {username_host_title}");
482
0
    let title_task = {
483
0
        let console_title = console_title.clone();
484
0
        async move {
485
            loop {
486
                // Set the console title (child might overwrite it, so we have to keep checking it)
487
0
                if console_title != get_console_title(api) {
488
0
                    api.set_console_title(console_title.as_str())
489
0
                        .unwrap_or_else(|err| {
490
0
                            error!("Failed to set console title: {}", err);
491
0
                        });
492
0
                }
493
0
                tokio::time::sleep(Duration::from_millis(5)).await;
494
            }
495
        }
496
    };
497
0
    let child_task = async {
498
0
        let mut child = launch_ssh_process(&resolved_username, host, port, config).await;
499
0
        run(api, &mut child).await;
500
0
        return child;
501
0
    };
502
503
    // Use tokio::select to run both tasks concurrently
504
0
    let child = tokio::select! {
505
0
        child = child_task => child,
506
0
        _ = title_task => {
507
0
            panic!("Title task should never complete");
508
        }
509
    };
510
511
    // Make sure the client and all its subprocesses
512
    // are aware they need to shutdown.
513
0
    api.generate_console_ctrl_event(0, 0).unwrap_or_else(|err| {
514
0
        error!("{}", err);
515
0
        panic!("Failed to send `ctrl + c` to remaining client windows",)
516
    });
517
0
    drop(child);
518
0
}
519
520
#[cfg(test)]
521
#[path = "../tests/client/test_mod.rs"]
522
mod test_mod;